all repos — weimar @ 9b2b569d75975b2ae6868e6708a18ee78a305125

Unnamed repository; edit this file 'description' to name the repository.

web/src/routes/image/[id]/+page.svelte (view raw)

  1<script lang="ts">
  2	import { onMount } from 'svelte';
  3	import { getImage, addTags, removeTags, suggestTags, toggleGem, imageDownloadUrl, type ImageDetail } from '$lib/api';
  4
  5	let { params } = $props();
  6	let id = $derived(parseInt(params.id));
  7
  8	let img = $state<ImageDetail | null>(null);
  9	let loading = $state(true);
 10	let error = $state('');
 11
 12	let tagInput = $state('');
 13	let suggestions = $state<string[]>([]);
 14	let showSuggestions = $state(false);
 15	let sugIndex = $state(-1);
 16	let tagError = $state('');
 17
 18	onMount(async () => {
 19		try {
 20			img = await getImage(id);
 21		} catch (err) {
 22			error = err instanceof Error ? err.message : 'Failed to load image';
 23		} finally {
 24			loading = false;
 25		}
 26	});
 27
 28	function formatSize(bytes: number): string {
 29		if (bytes < 1024) return bytes + ' B';
 30		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
 31		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
 32	}
 33
 34	function formatDate(s: string): string {
 35		return new Date(s).toLocaleString();
 36	}
 37
 38	async function onTagInput() {
 39		sugIndex = -1;
 40		const q = tagInput.split(',').pop()?.trim() || '';
 41		if (q.length < 1) {
 42			suggestions = [];
 43			showSuggestions = false;
 44			return;
 45		}
 46		try {
 47			const result = await suggestTags(q);
 48			suggestions = result.map((t) => t.tag);
 49			showSuggestions = suggestions.length > 0;
 50		} catch {
 51			suggestions = [];
 52			showSuggestions = false;
 53		}
 54	}
 55
 56	function selectSuggestion(s: string) {
 57		const parts = tagInput.split(',').slice(0, -1);
 58		parts.push(s);
 59		tagInput = parts.join(', ') + ', ';
 60		showSuggestions = false;
 61		sugIndex = -1;
 62	}
 63
 64	async function handleAddTags() {
 65		if (!img) return;
 66		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 67		if (tags.length === 0) return;
 68		tagError = '';
 69		try {
 70			const result = await addTags(img.id, tags);
 71			img.tags_detail = result.tags_detail;
 72			img.tags = result.tags_detail.map((t) => t.tag);
 73			tagInput = '';
 74		} catch (err) {
 75			tagError = err instanceof Error ? err.message : 'Failed to add tags';
 76		}
 77	}
 78
 79	async function handleRemoveTags() {
 80		if (!img) return;
 81		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 82		if (tags.length === 0) return;
 83		tagError = '';
 84		try {
 85			const result = await removeTags(img.id, tags);
 86			img.tags_detail = result.tags_detail;
 87			img.tags = result.tags_detail.map((t) => t.tag);
 88			tagInput = '';
 89		} catch (err) {
 90			tagError = err instanceof Error ? err.message : 'Failed to remove tags';
 91		}
 92	}
 93
 94	// Auto-scroll active suggestion into view.
 95	$effect(() => {
 96		if (sugIndex >= 0) {
 97			const el = document.querySelector('.sug-btn.active') as HTMLElement | null;
 98			if (el) el.scrollIntoView({ block: 'nearest' });
 99		}
100	});
101</script>
102
103{#if loading}
104	<p class="state-msg">Loading...</p>
105{:else if error}
106	<p class="state-msg error">{error}</p>
107{:else if img}
108	<div class="page-head">
109		<h1>{img.filename}</h1>
110		<div class="head-line"></div>
111	</div>
112
113	<div class="layout">
114		<div class="preview">
115			{#if img.thumbnail_path}
116				<img src={imageDownloadUrl(img.id)} alt={img.filename} />
117			{:else}
118				<div class="no-thumb">NO THUMBNAIL</div>
119			{/if}
120		</div>
121
122		<div class="meta">
123			<div class="meta-block uploader-block">
124				<span class="meta-label">UPLOADED BY</span>
125				<div class="uploader-row">
126					{#if img.avatar_url}
127						<img src={img.avatar_url} alt="" class="avatar-img" />
128					{/if}
129					<a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
130				</div>
131			</div>
132			<div class="meta-block">
133				<span class="meta-label">ID</span>
134				<span class="meta-value">#{img.id}</span>
135			</div>
136			<div class="meta-block">
137				<span class="meta-label">DIMENSIONS</span>
138				<span class="meta-value">{img.width} &times; {img.height}</span>
139			</div>
140			<div class="meta-block">
141				<span class="meta-label">FILE SIZE</span>
142				<span class="meta-value">{formatSize(img.file_size)}</span>
143			</div>
144			<div class="meta-block">
145				<span class="meta-label">TYPE</span>
146				<span class="meta-value">{img.mime_type}</span>
147			</div>
148			<div class="meta-block">
149				<span class="meta-label">UPLOADED</span>
150				<span class="meta-value">{formatDate(img.created_at)}</span>
151			</div>
152			<div class="meta-block tags-block">
153				<span class="meta-label">TAGS</span>
154				<div class="tag-pills">
155					{#if img.tags_detail.length > 0 || (img.innate_tags && img.innate_tags.length > 0)}
156						{#each img.tags_detail as t (t.tag)}
157							<a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
158								<span class="tag-name">{t.tag}</span>
159								<span class="tag-count">{t.usage_count}</span>
160							</a>
161						{/each}
162						{#if img.innate_tags}
163							{#each img.innate_tags as t (t)}
164								<a href="/browse?tag={encodeURIComponent(t)}" class="tag-pill innate">
165									<span class="tag-name">{t}</span>
166								</a>
167							{/each}
168						{/if}
169					{:else}
170						<span class="none">none</span>
171					{/if}
172				</div>
173			</div>
174
175			{#if img.is_owner}
176				<div class="meta-block edit-tags-block">
177					<span class="meta-label">EDIT TAGS</span>
178					<div class="tag-input-row">
179						<div class="tag-wrapper">
180							<input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
181								oninput={onTagInput}
182								onkeydown={(e) => {
183									if (e.key === 'Enter') {
184										if (sugIndex >= 0 && sugIndex < suggestions.length) {
185											e.preventDefault();
186											selectSuggestion(suggestions[sugIndex]);
187										}
188									} else if (e.key === 'Tab' || e.key === 'ArrowDown') {
189										if (showSuggestions && suggestions.length > 0) {
190											e.preventDefault();
191											sugIndex = (sugIndex + 1) % suggestions.length;
192										}
193									} else if (e.key === 'ArrowUp') {
194										if (showSuggestions && suggestions.length > 0) {
195											e.preventDefault();
196											sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1;
197										}
198									} else if (e.key === 'Escape') {
199										showSuggestions = false;
200										sugIndex = -1;
201									}
202								}}
203								onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)}
204							/>
205							{#if showSuggestions}
206								<div class="suggestions">
207									{#each suggestions as s, i}
208										<button type="button" class="sug-btn" class:active={i === sugIndex} onmousedown={() => selectSuggestion(s)}>{s}</button>
209									{/each}
210								</div>
211							{/if}
212						</div>
213						<button class="tag-btn add" onclick={handleAddTags}>ADD</button>
214						<button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
215					</div>
216					{#if tagError}
217						<span class="tag-err">{tagError}</span>
218					{/if}
219				</div>
220			{/if}
221
222			<div class="meta-actions">
223				<a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
224					DOWNLOAD ORIGINAL
225				</a>
226				<button class="gem-btn" class:gemmed={img.gemmed} onclick={async () => {
227					try {
228						const r = await toggleGem(img.id);
229						img.gemmed = r.gemmed;
230						img.gem_count = r.gem_count;
231					} catch (err) {
232						console.error('gem toggle failed:', err);
233					}
234				}} aria-label="Toggle gem">
235					<svg class="gem-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
236						<polygon points="6,3 18,3 22,9 12,21 2,9" stroke-linejoin="round"/>
237					</svg>
238					<span class="gem-count">{img.gem_count || 0}</span>
239				</button>
240			</div>
241		</div>
242	</div>
243
244	<p class="back"><a href="/browse">&larr; BACK TO BROWSE</a></p>
245{/if}
246
247<style>
248	.state-msg {
249		padding: 2rem 0;
250		font-size: 1.1rem;
251		color: #888;
252	}
253	.error {
254		color: #c62828;
255	}
256
257	.page-head {
258		margin-bottom: 1.5rem;
259	}
260	.page-head h1 {
261		word-break: break-all;
262	}
263	.head-line {
264		height: 3px;
265		background: #c62828;
266		width: 3rem;
267		margin-top: 0.4rem;
268	}
269
270	.layout {
271		display: flex;
272		gap: 2rem;
273		flex-wrap: wrap;
274	}
275	.preview {
276		flex: 1;
277		min-width: 300px;
278		background: #1a1a1a;
279		display: flex;
280		align-items: center;
281		justify-content: center;
282	}
283	.preview img {
284		display: block;
285		width: 100%;
286		height: auto;
287		max-height: 70vh;
288		object-fit: contain;
289	}
290	.no-thumb {
291		padding: 4rem 2rem;
292		color: #555;
293		font-family: 'Oswald', sans-serif;
294		font-weight: 500;
295		font-size: 0.9rem;
296		letter-spacing: 0.12em;
297	}
298
299	.meta {
300		width: 260px;
301		flex-shrink: 0;
302		display: flex;
303		flex-direction: column;
304		gap: 1rem;
305	}
306	.meta-block {
307		display: flex;
308		flex-direction: column;
309		gap: 0.15rem;
310	}
311	.meta-label {
312		font-family: 'Oswald', sans-serif;
313		font-weight: 500;
314		font-size: 0.7rem;
315		letter-spacing: 0.15em;
316		color: #999;
317	}
318	.meta-value {
319		font-family: 'Inter', sans-serif;
320		font-size: 0.9rem;
321		font-weight: 600;
322		color: #1a1a1a;
323	}
324
325	.uploader-row {
326		display: flex;
327		align-items: center;
328		gap: 0.4rem;
329	}
330	.uploader-link {
331		color: #1a1a1a;
332		text-decoration: none;
333	}
334	.uploader-link:hover {
335		color: #c62828;
336		text-decoration: underline;
337	}
338
339	.avatar-img {
340		width: 1.5rem;
341		height: 1.5rem;
342		object-fit: cover;
343		flex-shrink: 0;
344	}
345
346	.tags-block {
347		flex: 1;
348	}
349	.tag-pills {
350		display: flex;
351		flex-wrap: wrap;
352		gap: 0.35rem;
353	}
354	.tag-pill {
355		display: inline-flex;
356		align-items: center;
357		gap: 0.3rem;
358		background: #1a1a1a;
359		padding: 0.25rem 0.5rem 0.25rem 0.65rem;
360		font-family: 'Inter', sans-serif;
361		font-size: 0.78rem;
362		font-weight: 500;
363		color: #eee;
364		text-decoration: none;
365		transition: background 0.15s;
366	}
367	.tag-pill:hover {
368		background: #c62828;
369		text-decoration: none;
370	}
371	.tag-name {
372		font-weight: 600;
373	}
374	.tag-count {
375		background: rgba(255, 255, 255, 0.12);
376		padding: 0.05rem 0.35rem;
377		font-size: 0.68rem;
378		font-weight: 600;
379		color: rgba(255, 255, 255, 0.65);
380		min-width: 1.1rem;
381		text-align: center;
382	}
383	.tag-pill:hover .tag-count {
384		background: rgba(0, 0, 0, 0.2);
385	}
386	.tag-pill.innate {
387		background: #1565c0;
388	}
389	.tag-pill.innate:hover {
390		background: #1976d2;
391		text-decoration: none;
392	}
393	.tag-pill.innate .tag-count {
394		display: none;
395	}
396	.none {
397		color: #bbb;
398		font-size: 0.85rem;
399	}
400
401	.btn {
402		display: inline-flex;
403		align-items: center;
404		align-self: flex-start;
405		padding: 0.55rem 1.25rem;
406		background: #c62828;
407		color: #fff;
408		border: none;
409		font-family: 'Oswald', sans-serif;
410		font-weight: 500;
411		font-size: 0.8rem;
412		letter-spacing: 0.1em;
413		text-decoration: none;
414		text-transform: uppercase;
415		cursor: pointer;
416		transition: background 0.15s;
417		margin-top: 0.5rem;
418	}
419	.btn:hover {
420		background: #b71c1c;
421		text-decoration: none;
422	}
423	.meta-actions {
424		display: flex;
425		align-items: center;
426		gap: 0.5rem;
427		margin-top: 0.5rem;
428	}
429	.gem-btn {
430		display: flex;
431		align-items: center;
432		gap: 0.25rem;
433		background: none;
434		border: 2px solid #1a1a1a;
435		color: #1a1a1a;
436		padding: 0.45rem 0.6rem 0.45rem 0.5rem;
437		cursor: pointer;
438		transition: border-color 0.15s, color 0.15s, background 0.15s;
439		font: inherit;
440		line-height: 1;
441	}
442	.gem-btn:hover {
443		border-color: #c62828;
444		color: #c62828;
445	}
446	.gem-btn.gemmed {
447		border-color: #c62828;
448		color: #c62828;
449		background: rgba(198,40,40,0.08);
450	}
451	.gem-btn.gemmed:hover {
452		background: rgba(198,40,40,0.15);
453	}
454	.gem-icon {
455		display: block;
456	}
457	.gem-count {
458		font-family: 'Oswald', sans-serif;
459		font-size: 0.85rem;
460		font-weight: 600;
461		min-width: 1rem;
462		text-align: center;
463	}
464
465	.back {
466		margin-top: 2rem;
467	}
468	.back a {
469		font-family: 'Oswald', sans-serif;
470		font-weight: 500;
471		font-size: 0.85rem;
472		letter-spacing: 0.1em;
473	}
474
475	.edit-tags-block {
476		border-top: 2px solid #1a1a1a;
477		padding-top: 0.75rem;
478	}
479	.tag-input-row {
480		display: flex;
481		gap: 0.35rem;
482		align-items: flex-start;
483	}
484	.tag-wrapper {
485		flex: 1;
486		position: relative;
487		min-width: 0;
488	}
489	.tag-wrapper input[type="text"] {
490		width: 100%;
491		padding: 0.4rem 0.5rem;
492		border: 2px solid #1a1a1a;
493		border-radius: 0;
494		font-family: 'Inter', sans-serif;
495		font-size: 0.78rem;
496		font-weight: 500;
497		background: #fff;
498		box-sizing: border-box;
499	}
500	.tag-wrapper input[type="text"]:focus {
501		outline: none;
502		border-color: #c62828;
503	}
504	.suggestions {
505		position: absolute;
506		top: 100%;
507		left: 0;
508		right: 0;
509		background: #fff;
510		border: 2px solid #1a1a1a;
511		border-top: none;
512		margin: 0;
513		padding: 0;
514		z-index: 10;
515		max-height: 130px;
516		overflow-y: auto;
517	}
518	.sug-btn {
519		display: block;
520		width: 100%;
521		text-align: left;
522		padding: 0.35rem 0.5rem;
523		border: none;
524		background: none;
525		font-family: 'Inter', sans-serif;
526		font-size: 0.78rem;
527		font-weight: 500;
528		cursor: pointer;
529		border-radius: 0;
530	}
531	.sug-btn:hover {
532		background: #f0f0f0;
533	}
534	.sug-btn.active {
535		background: #c62828;
536		color: #fff;
537	}
538	.tag-btn {
539		padding: 0.4rem 0.65rem;
540		border: none;
541		font-family: 'Oswald', sans-serif;
542		font-weight: 500;
543		font-size: 0.72rem;
544		letter-spacing: 0.08em;
545		cursor: pointer;
546		white-space: nowrap;
547		transition: background 0.15s;
548	}
549	.tag-btn.add {
550		background: #1a1a1a;
551		color: #fff;
552	}
553	.tag-btn.add:hover {
554		background: #2e7d32;
555	}
556	.tag-btn.remove {
557		background: #1a1a1a;
558		color: #fff;
559	}
560	.tag-btn.remove:hover {
561		background: #c62828;
562	}
563	.tag-err {
564		display: block;
565		margin-top: 0.3rem;
566		color: #c62828;
567		font-family: 'Inter', sans-serif;
568		font-size: 0.72rem;
569		font-weight: 600;
570	}
571</style>